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
nativegate/golden.py
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""Numerical regression harness: prove the answers did not change.
|
|
2
|
+
|
|
3
|
+
For a migration of decades-old PVT, well and reservoir code, "it builds and
|
|
4
|
+
imports" is not the acceptance criterion — *the numbers are the same* is. The
|
|
5
|
+
things that silently change them are exactly the things nativegate touches:
|
|
6
|
+
a regenerate that picks a different overload, a compiler or flag change, a
|
|
7
|
+
`-ffast-math` creeping into a CMake preset, a parser upgrade that maps a type
|
|
8
|
+
differently, a refactor of the native code underneath.
|
|
9
|
+
|
|
10
|
+
So: record every bound entry point's output for a fixed set of inputs once,
|
|
11
|
+
commit that file, and compare on every build.
|
|
12
|
+
|
|
13
|
+
Design constraints that shaped this:
|
|
14
|
+
|
|
15
|
+
* **Inputs must be reproducible from the IR alone.** They are derived from
|
|
16
|
+
each parameter's position and type, so re-recording on another machine
|
|
17
|
+
produces the same call — no random data, no timestamps in the file.
|
|
18
|
+
* **Comparison is float-aware, and the tolerance is argued for.** See
|
|
19
|
+
"Choosing a tolerance" below. Every entry carries its own tolerance in the
|
|
20
|
+
file, so the number a given routine is held to is visible in review rather
|
|
21
|
+
than hidden in a global default.
|
|
22
|
+
* **The environment is recorded with the values.** A golden failure asks one
|
|
23
|
+
question first — "did the toolchain change?" — and answering it after the
|
|
24
|
+
fact is impossible if the file only holds numbers. See "Provenance".
|
|
25
|
+
* **In-place outputs are recorded too.** Fortran routines with `intent(inout)`
|
|
26
|
+
array arguments return `None` and write their answer into the caller's
|
|
27
|
+
array. Recording only the return value would record `None` and prove
|
|
28
|
+
nothing, so arguments that the call modified are recorded as well.
|
|
29
|
+
* **Call order is preserved.** Legacy Fortran keeps state in COMMON blocks:
|
|
30
|
+
an initialisation routine sets up what the next call reads. Entries are
|
|
31
|
+
therefore recorded and replayed in the order the IR exposes them, not
|
|
32
|
+
sorted — sorting them alphabetically silently changed the answers.
|
|
33
|
+
* **Coverage is visible.** Entry points that cannot be called with generated
|
|
34
|
+
inputs are recorded as skipped, with the reason, so a golden file that
|
|
35
|
+
covers three of forty symbols cannot be mistaken for a clean bill of health.
|
|
36
|
+
|
|
37
|
+
Choosing a tolerance
|
|
38
|
+
--------------------
|
|
39
|
+
|
|
40
|
+
The default was 1e-12 relative with a zero absolute floor. That is roughly
|
|
41
|
+
4,500 ULP of double precision, which sounds generous and is not, because the
|
|
42
|
+
things it has to survive are not rounding of a single operation:
|
|
43
|
+
|
|
44
|
+
* **libm differs between platforms.** `pow`, `exp`, `log10` and `asin` are not
|
|
45
|
+
correctly rounded in any mainstream libm. Apple's, glibc's and musl's
|
|
46
|
+
implementations disagree by a few ULP on the same argument, and these
|
|
47
|
+
correlations are nothing *but* chained transcendentals — Standing and
|
|
48
|
+
Vazquez-Beggs are products of `10**x` and `p**1.2048`.
|
|
49
|
+
* **The chain amplifies.** The relative error of `10**x` is about
|
|
50
|
+
`|x·ln 10|` times the relative error of `x`; with `x ≈ 3` that is a factor
|
|
51
|
+
of seven per exponentiation, and there are several per correlation.
|
|
52
|
+
* **FMA contraction is an optimisation-flag decision.** `-O2` on one target
|
|
53
|
+
and `-O3` on another fuse a different set of multiply-adds, each fusion
|
|
54
|
+
worth up to a ULP, and none of that is visible in the source.
|
|
55
|
+
* **Iteration terminates on a threshold.** A ULP of difference in a residual
|
|
56
|
+
can put a Newton or fixed-point loop on the other side of its convergence
|
|
57
|
+
test, so the two platforms take a different number of iterations. The
|
|
58
|
+
resulting difference is set by *the routine's own convergence tolerance*,
|
|
59
|
+
not by machine epsilon, and no global default can know what that is.
|
|
60
|
+
|
|
61
|
+
So the default is 1e-9 relative. That is ~4.5 million ULP of headroom, which
|
|
62
|
+
covers every effect above with room to spare — and it is still six to seven
|
|
63
|
+
orders of magnitude tighter than any change that could be called a different
|
|
64
|
+
answer. These correlations agree with laboratory data to about ±10%; a
|
|
65
|
+
changed coefficient, a dropped unit conversion, a swapped argument pair, a
|
|
66
|
+
different overload, or `-ffast-math` all move a result by 1e-3 relative or
|
|
67
|
+
more. 1e-9 cannot hide any of them.
|
|
68
|
+
|
|
69
|
+
The absolute floor is 1e-12 rather than 0.0 so that a quantity which is
|
|
70
|
+
legitimately zero, or denormal-small, does not fail on a difference that is
|
|
71
|
+
physically meaningless. Every physical quantity in this domain is far above
|
|
72
|
+
it: the smallest, gas compressibility, is ~1e-5 /psi.
|
|
73
|
+
|
|
74
|
+
Where a routine's own algorithm is looser than 1e-9, the *entry* says so
|
|
75
|
+
(`entries[key]["tolerance"]`) with a reason. That is the honest form: it
|
|
76
|
+
records that `tubing_bhp` is a marching fixed-point solve that stops at 1e-4
|
|
77
|
+
relative on segment midpoint pressure, rather than silently loosening every
|
|
78
|
+
entry in the file to accommodate the worst one.
|
|
79
|
+
|
|
80
|
+
Provenance
|
|
81
|
+
----------
|
|
82
|
+
|
|
83
|
+
A golden failure has two possible causes and they need completely different
|
|
84
|
+
responses: the code changed (a regression — fix it), or the toolchain changed
|
|
85
|
+
(a different compiler, libm or flag — investigate, then re-record
|
|
86
|
+
deliberately). Recording only the numbers makes that undecidable after the
|
|
87
|
+
fact, and the documented remedy — re-record — destroys the evidence. So the
|
|
88
|
+
file records the platform, the interpreter, numpy, the Fortran and C++
|
|
89
|
+
compiler identifications, nativegate's own version, and the SHA-256 of every
|
|
90
|
+
native source the values came from. On a mismatch the comparison prints
|
|
91
|
+
recorded against current, so the first question is answered before anyone
|
|
92
|
+
opens a terminal.
|
|
93
|
+
|
|
94
|
+
None of it varies run to run on the same machine: no timestamps, no paths, no
|
|
95
|
+
hostnames, no user names. The file is diffed in review.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
import hashlib
|
|
101
|
+
import json
|
|
102
|
+
import math
|
|
103
|
+
import platform
|
|
104
|
+
import shutil
|
|
105
|
+
import subprocess
|
|
106
|
+
from dataclasses import dataclass
|
|
107
|
+
from functools import lru_cache
|
|
108
|
+
from pathlib import Path
|
|
109
|
+
|
|
110
|
+
from .ir import ClassDef, ModuleIR, StructDef
|
|
111
|
+
|
|
112
|
+
GOLDEN_FILENAME = "golden.json"
|
|
113
|
+
|
|
114
|
+
# 2: added per-entry tolerances, the provenance block and argument_effects.
|
|
115
|
+
FORMAT_VERSION = 2
|
|
116
|
+
|
|
117
|
+
# See "Choosing a tolerance" above — this number is argued for, not picked.
|
|
118
|
+
DEFAULT_RTOL = 1e-9
|
|
119
|
+
DEFAULT_ATOL = 1e-12
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- deterministic sample inputs ----------------------------------------
|
|
123
|
+
|
|
124
|
+
# Values are picked per parameter *position* so a signature always gets the
|
|
125
|
+
# same call, and are spread across the range rather than repeated so an
|
|
126
|
+
# argument-order regression (a swapped pair) actually changes an answer.
|
|
127
|
+
_FLOATS = (1.0, 2.5, 0.75, 10.0, 3.25, 0.5, 7.5, 100.0)
|
|
128
|
+
_INTS = (1, 2, 3, 4, 5, 6, 7, 8)
|
|
129
|
+
_STRINGS = ("alpha", "beta", "gamma", "delta")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _scalar(kind: str, index: int):
|
|
133
|
+
if kind == "float":
|
|
134
|
+
return _FLOATS[index % len(_FLOATS)]
|
|
135
|
+
if kind == "int":
|
|
136
|
+
return _INTS[index % len(_INTS)]
|
|
137
|
+
if kind == "bool":
|
|
138
|
+
return index % 2 == 0
|
|
139
|
+
if kind == "str":
|
|
140
|
+
return _STRINGS[index % len(_STRINGS)]
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Unsupported(Exception):
|
|
145
|
+
"""No deterministic input can be built for this signature."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def sample_arguments(parameters, structs: dict, offset: int = 0) -> list:
|
|
149
|
+
"""Concrete arguments for one call, as plain Python values."""
|
|
150
|
+
arguments = []
|
|
151
|
+
for index, param in enumerate(parameters, start=offset):
|
|
152
|
+
if param.type in structs:
|
|
153
|
+
arguments.append(
|
|
154
|
+
{
|
|
155
|
+
"__struct__": param.type,
|
|
156
|
+
"fields": _struct_sample(structs[param.type], structs, index),
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
value = _scalar(param.type, index)
|
|
161
|
+
if value is None:
|
|
162
|
+
raise Unsupported(
|
|
163
|
+
f"parameter '{param.name}' has type '{param.type}', which the "
|
|
164
|
+
"golden harness cannot generate an input for"
|
|
165
|
+
)
|
|
166
|
+
if not param.is_array:
|
|
167
|
+
arguments.append(value)
|
|
168
|
+
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
169
|
+
# Distinct elements, so a routine that only reads element 0 (or
|
|
170
|
+
# reverses the array) produces a different answer than one that
|
|
171
|
+
# sums it.
|
|
172
|
+
arguments.append([value, value + 1, value + 2])
|
|
173
|
+
else:
|
|
174
|
+
arguments.append([value, value, value])
|
|
175
|
+
return arguments
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _struct_sample(struct: StructDef, structs: dict, index: int) -> dict:
|
|
179
|
+
fields = {}
|
|
180
|
+
for offset, field in enumerate(struct.fields):
|
|
181
|
+
if field.type in structs:
|
|
182
|
+
fields[field.name] = {
|
|
183
|
+
"__struct__": field.type,
|
|
184
|
+
"fields": _struct_sample(structs[field.type], structs, index + offset),
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
value = _scalar(field.type, index + offset)
|
|
188
|
+
if value is None:
|
|
189
|
+
raise Unsupported(
|
|
190
|
+
f"field '{struct.name}.{field.name}' has type '{field.type}'"
|
|
191
|
+
)
|
|
192
|
+
fields[field.name] = value
|
|
193
|
+
return fields
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- the call plan ------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class Call:
|
|
201
|
+
"""One recorded entry point: what to call, and with what."""
|
|
202
|
+
|
|
203
|
+
key: str # "Class.method" or "function"
|
|
204
|
+
kind: str # "method" | "static" | "function"
|
|
205
|
+
class_name: str | None
|
|
206
|
+
name: str
|
|
207
|
+
constructor_arguments: list
|
|
208
|
+
arguments: list
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class Skip:
|
|
213
|
+
key: str
|
|
214
|
+
reason: str
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def plan(module: ModuleIR) -> tuple[list[Call], list[Skip]]:
|
|
218
|
+
"""Every entry point worth recording, and every one that cannot be.
|
|
219
|
+
|
|
220
|
+
Derived from the IR only, so `record` and `verify` agree without either
|
|
221
|
+
reading the other's file.
|
|
222
|
+
"""
|
|
223
|
+
structs = {struct.name: struct for struct in module.structs}
|
|
224
|
+
calls: list[Call] = []
|
|
225
|
+
skips: list[Skip] = []
|
|
226
|
+
|
|
227
|
+
for cls in module.classes:
|
|
228
|
+
ctor_args, ctor_error = _constructor_arguments(cls, structs)
|
|
229
|
+
for method in cls.methods:
|
|
230
|
+
key = f"{cls.name}.{method.name}"
|
|
231
|
+
if not method.is_static and ctor_error is not None:
|
|
232
|
+
skips.append(Skip(key, ctor_error))
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
arguments = sample_arguments(method.parameters, structs)
|
|
236
|
+
except Unsupported as exc:
|
|
237
|
+
skips.append(Skip(key, str(exc)))
|
|
238
|
+
continue
|
|
239
|
+
calls.append(
|
|
240
|
+
Call(
|
|
241
|
+
key=key,
|
|
242
|
+
kind="static" if method.is_static else "method",
|
|
243
|
+
class_name=cls.name,
|
|
244
|
+
name=method.name,
|
|
245
|
+
constructor_arguments=[] if method.is_static else ctor_args,
|
|
246
|
+
arguments=arguments,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
for fn in module.functions:
|
|
251
|
+
# An intent(out) argument is supplied by Fortran, not the caller.
|
|
252
|
+
inputs = [p for p in fn.parameters if p.intent != "out"]
|
|
253
|
+
try:
|
|
254
|
+
arguments = sample_arguments(inputs, structs)
|
|
255
|
+
except Unsupported as exc:
|
|
256
|
+
skips.append(Skip(fn.name, str(exc)))
|
|
257
|
+
continue
|
|
258
|
+
calls.append(
|
|
259
|
+
Call(
|
|
260
|
+
key=fn.name,
|
|
261
|
+
kind="function",
|
|
262
|
+
class_name=None,
|
|
263
|
+
name=fn.name,
|
|
264
|
+
constructor_arguments=[],
|
|
265
|
+
arguments=arguments,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return calls, skips
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _constructor_arguments(cls: ClassDef, structs: dict) -> tuple[list, str | None]:
|
|
273
|
+
if cls.has_default_constructor:
|
|
274
|
+
return [], None
|
|
275
|
+
if not cls.constructors:
|
|
276
|
+
return [], (
|
|
277
|
+
"no public constructor callable from Python (abstract, or all "
|
|
278
|
+
"constructors are non-public)"
|
|
279
|
+
)
|
|
280
|
+
try:
|
|
281
|
+
return sample_arguments(min(cls.constructors, key=len), structs), None
|
|
282
|
+
except Unsupported as exc:
|
|
283
|
+
return [], f"constructor is not callable with generated inputs: {exc}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# --- running the plan ---------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _materialise(value, package):
|
|
290
|
+
"""Turn a recorded argument into the object the native call wants.
|
|
291
|
+
|
|
292
|
+
A numeric list becomes a numpy array when numpy is importable. f2py binds
|
|
293
|
+
an `intent(inout)` array as a genuine in-place output: it rejects a plain
|
|
294
|
+
list outright, and even where it accepts a sequence it writes into a copy
|
|
295
|
+
the caller never sees. Passing an array is what makes those outputs
|
|
296
|
+
observable at all. pybind11's sequence casters accept an array, so this is
|
|
297
|
+
safe for C++ too.
|
|
298
|
+
"""
|
|
299
|
+
if isinstance(value, dict) and "__struct__" in value:
|
|
300
|
+
native = getattr(package, value["__struct__"])()
|
|
301
|
+
for name, field in value["fields"].items():
|
|
302
|
+
setattr(native, name, _materialise(field, package))
|
|
303
|
+
return native
|
|
304
|
+
if isinstance(value, list) and value and all(_is_number(v) for v in value):
|
|
305
|
+
numpy = _numpy()
|
|
306
|
+
if numpy is not None:
|
|
307
|
+
dtype = "int64" if all(isinstance(v, int) for v in value) else "float64"
|
|
308
|
+
return numpy.array(value, dtype=dtype)
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_number(value) -> bool:
|
|
313
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _numpy():
|
|
317
|
+
try:
|
|
318
|
+
import numpy
|
|
319
|
+
except ImportError: # pragma: no cover - numpy is present wherever we build
|
|
320
|
+
return None
|
|
321
|
+
return numpy
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _jsonable(value):
|
|
325
|
+
"""Whatever a native call returned, as plain JSON data.
|
|
326
|
+
|
|
327
|
+
A struct comes back as a pybind11 object and a Fortran array as a numpy
|
|
328
|
+
array; both have to become data before they can be compared to a file.
|
|
329
|
+
"""
|
|
330
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
331
|
+
return value
|
|
332
|
+
if isinstance(value, (list, tuple)):
|
|
333
|
+
return [_jsonable(v) for v in value]
|
|
334
|
+
if hasattr(value, "tolist"): # numpy array
|
|
335
|
+
return _jsonable(value.tolist())
|
|
336
|
+
fields = {
|
|
337
|
+
name: getattr(value, name)
|
|
338
|
+
for name in dir(value)
|
|
339
|
+
if not name.startswith("_") and not callable(getattr(value, name))
|
|
340
|
+
}
|
|
341
|
+
return {name: _jsonable(field) for name, field in fields.items()}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def run(module: ModuleIR, package, existing: dict | None = None) -> tuple[dict, list[Skip]]:
|
|
345
|
+
"""Call every planned entry point on an imported package.
|
|
346
|
+
|
|
347
|
+
Generated inputs are a starting point, not the goal: `1.0` for an API
|
|
348
|
+
gravity is outside every PVT correlation's valid range, and a baseline
|
|
349
|
+
taken there pins the behaviour of an extrapolation rather than of the
|
|
350
|
+
correlation. So arguments already present in `existing` are reused
|
|
351
|
+
verbatim — edit them in golden.json to values your engineers recognise,
|
|
352
|
+
re-record, and those inputs survive every future re-record.
|
|
353
|
+
|
|
354
|
+
Returns {key: entry} plus the skips. An entry point that raises is
|
|
355
|
+
recorded as a skip rather than aborting the run: one bad symbol should not
|
|
356
|
+
cost you the other thirty-nine.
|
|
357
|
+
"""
|
|
358
|
+
calls, skips = plan(module)
|
|
359
|
+
previous = (existing or {}).get("entries") or {}
|
|
360
|
+
entries: dict = {}
|
|
361
|
+
|
|
362
|
+
for call in calls:
|
|
363
|
+
spec = call_spec(call)
|
|
364
|
+
kept = previous.get(call.key)
|
|
365
|
+
if kept is not None:
|
|
366
|
+
spec["arguments"] = kept.get("arguments", spec["arguments"])
|
|
367
|
+
spec["constructor_arguments"] = kept.get(
|
|
368
|
+
"constructor_arguments", spec["constructor_arguments"]
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
result, effects = invoke(spec, package)
|
|
372
|
+
except Exception as exc: # noqa: BLE001 - recorded, not swallowed
|
|
373
|
+
skips.append(Skip(call.key, f"{type(exc).__name__}: {exc}"))
|
|
374
|
+
continue
|
|
375
|
+
entry = {**spec, "result": result}
|
|
376
|
+
if effects:
|
|
377
|
+
entry["argument_effects"] = effects
|
|
378
|
+
entries[call.key] = entry
|
|
379
|
+
|
|
380
|
+
return entries, skips
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def call_spec(call: Call) -> dict:
|
|
384
|
+
return {
|
|
385
|
+
"kind": call.kind,
|
|
386
|
+
"class": call.class_name,
|
|
387
|
+
"name": call.name,
|
|
388
|
+
"constructor_arguments": call.constructor_arguments,
|
|
389
|
+
"arguments": call.arguments,
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _invoke(entry: dict, package):
|
|
394
|
+
"""Execute one recorded call against an imported package."""
|
|
395
|
+
return invoke(entry, package)[0]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def invoke(entry: dict, package) -> tuple:
|
|
399
|
+
"""Execute one recorded call; return (result, argument_effects).
|
|
400
|
+
|
|
401
|
+
`argument_effects` maps argument index (as a string, because it has to
|
|
402
|
+
survive JSON) to the argument's value *after* the call, for arguments the
|
|
403
|
+
call modified in place. A Fortran routine whose whole answer is written
|
|
404
|
+
into an `intent(inout)` array returns `None`; without this the golden file
|
|
405
|
+
would record `None` and assert nothing about the nine PVT properties the
|
|
406
|
+
call actually produced.
|
|
407
|
+
"""
|
|
408
|
+
arguments = [_materialise(a, package) for a in entry["arguments"]]
|
|
409
|
+
if entry["kind"] == "function":
|
|
410
|
+
target = getattr(package, entry["name"])
|
|
411
|
+
elif entry["kind"] == "static":
|
|
412
|
+
target = getattr(getattr(package, entry["class"]), entry["name"])
|
|
413
|
+
else:
|
|
414
|
+
cls = getattr(package, entry["class"])
|
|
415
|
+
instance = cls(
|
|
416
|
+
*[_materialise(a, package) for a in entry["constructor_arguments"]]
|
|
417
|
+
)
|
|
418
|
+
target = getattr(instance, entry["name"])
|
|
419
|
+
|
|
420
|
+
result = _jsonable(target(*arguments))
|
|
421
|
+
|
|
422
|
+
effects = {}
|
|
423
|
+
for index, (before, after) in enumerate(zip(entry["arguments"], arguments)):
|
|
424
|
+
if not isinstance(before, list):
|
|
425
|
+
continue
|
|
426
|
+
now = _jsonable(after)
|
|
427
|
+
if now != before:
|
|
428
|
+
effects[str(index)] = now
|
|
429
|
+
return result, effects
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def replay(document: dict, package, effects: dict | None = None) -> tuple[dict, dict]:
|
|
433
|
+
"""Re-run the calls recorded in a golden document.
|
|
434
|
+
|
|
435
|
+
The *file* drives this, not the IR: a golden run has to be able to say
|
|
436
|
+
"this entry point used to exist and no longer does", which is impossible
|
|
437
|
+
if the plan is regenerated from the current code every time.
|
|
438
|
+
|
|
439
|
+
Returns ({key: result}, {key: error message}). Pass a dict as `effects`
|
|
440
|
+
to also collect the in-place argument outputs of each call, and hand the
|
|
441
|
+
same dict to `compare` so they are checked.
|
|
442
|
+
"""
|
|
443
|
+
results: dict = {}
|
|
444
|
+
errors: dict = {}
|
|
445
|
+
for key, entry in (document.get("entries") or {}).items():
|
|
446
|
+
try:
|
|
447
|
+
result, produced = invoke(entry, package)
|
|
448
|
+
except Exception as exc: # noqa: BLE001
|
|
449
|
+
errors[key] = f"{type(exc).__name__}: {exc}"
|
|
450
|
+
continue
|
|
451
|
+
results[key] = result
|
|
452
|
+
if effects is not None:
|
|
453
|
+
effects[key] = produced
|
|
454
|
+
return results, errors
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --- provenance ---------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@lru_cache(maxsize=None)
|
|
461
|
+
def _tool_version(command: str) -> str | None:
|
|
462
|
+
"""The first line of `<command> --version`, or None if it is not here.
|
|
463
|
+
|
|
464
|
+
One line, because the rest is a copyright notice that changes with the
|
|
465
|
+
calendar and would make the file diff for no reason.
|
|
466
|
+
"""
|
|
467
|
+
executable = shutil.which(command)
|
|
468
|
+
if executable is None:
|
|
469
|
+
return None
|
|
470
|
+
try:
|
|
471
|
+
completed = subprocess.run(
|
|
472
|
+
[executable, "--version"],
|
|
473
|
+
capture_output=True,
|
|
474
|
+
text=True,
|
|
475
|
+
timeout=30,
|
|
476
|
+
check=False,
|
|
477
|
+
)
|
|
478
|
+
except OSError: # pragma: no cover - which() said it was there
|
|
479
|
+
return None
|
|
480
|
+
output = (completed.stdout or completed.stderr or "").strip().splitlines()
|
|
481
|
+
return output[0].strip() if output else None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _numpy_version() -> str | None:
|
|
485
|
+
numpy = _numpy()
|
|
486
|
+
return None if numpy is None else numpy.__version__
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _nativegate_version() -> str:
|
|
490
|
+
try:
|
|
491
|
+
from . import __version__
|
|
492
|
+
except ImportError: # pragma: no cover
|
|
493
|
+
return "unknown"
|
|
494
|
+
return str(__version__)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def source_digests(paths, root: Path | None = None) -> dict:
|
|
498
|
+
"""SHA-256 of each native source, keyed by a path that means something.
|
|
499
|
+
|
|
500
|
+
The digest is what makes "the code did not change" checkable rather than
|
|
501
|
+
asserted: a golden failure whose source digests are unchanged is a
|
|
502
|
+
toolchain difference, and one whose digests moved is a code change whose
|
|
503
|
+
diff you can go and read.
|
|
504
|
+
"""
|
|
505
|
+
digests = {}
|
|
506
|
+
for path in sorted(Path(p) for p in paths):
|
|
507
|
+
if not path.is_file():
|
|
508
|
+
continue
|
|
509
|
+
key = str(path.relative_to(root)) if root else path.name
|
|
510
|
+
digests[key] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
511
|
+
return digests
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def provenance(sources=None, root: Path | None = None) -> dict:
|
|
515
|
+
"""What produced these numbers.
|
|
516
|
+
|
|
517
|
+
Everything here is a property of the machine and the sources, never of
|
|
518
|
+
the moment — the file is diffed in review, so a timestamp would make
|
|
519
|
+
every re-record look like a change.
|
|
520
|
+
"""
|
|
521
|
+
return {
|
|
522
|
+
"platform": f"{platform.system()} {platform.release()}",
|
|
523
|
+
"machine": platform.machine(),
|
|
524
|
+
"python": platform.python_version(),
|
|
525
|
+
"python_implementation": platform.python_implementation(),
|
|
526
|
+
"numpy": _numpy_version(),
|
|
527
|
+
"fortran_compiler": _tool_version("gfortran"),
|
|
528
|
+
"cxx_compiler": _tool_version("clang++") or _tool_version("g++"),
|
|
529
|
+
"nativegate": _nativegate_version(),
|
|
530
|
+
"sources": source_digests(sources or [], root),
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# Ordered so the reported diff reads from "most likely to move a number" down.
|
|
535
|
+
_PROVENANCE_ORDER = (
|
|
536
|
+
"sources",
|
|
537
|
+
"fortran_compiler",
|
|
538
|
+
"cxx_compiler",
|
|
539
|
+
"platform",
|
|
540
|
+
"machine",
|
|
541
|
+
"numpy",
|
|
542
|
+
"python",
|
|
543
|
+
"python_implementation",
|
|
544
|
+
"nativegate",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def provenance_differences(recorded: dict | None, current: dict | None) -> list[str]:
|
|
549
|
+
"""Human-readable "recorded X, now Y" lines for each field that moved."""
|
|
550
|
+
if not recorded or not current:
|
|
551
|
+
return []
|
|
552
|
+
lines = []
|
|
553
|
+
keys = [k for k in _PROVENANCE_ORDER if k in recorded or k in current]
|
|
554
|
+
keys += sorted((set(recorded) | set(current)) - set(_PROVENANCE_ORDER))
|
|
555
|
+
for key in dict.fromkeys(keys):
|
|
556
|
+
was, now = recorded.get(key), current.get(key)
|
|
557
|
+
if was == now:
|
|
558
|
+
continue
|
|
559
|
+
if key == "sources":
|
|
560
|
+
for name in sorted(set(was or {}) | set(now or {})):
|
|
561
|
+
a, b = (was or {}).get(name), (now or {}).get(name)
|
|
562
|
+
if a != b:
|
|
563
|
+
lines.append(
|
|
564
|
+
f" source {name}: recorded {a or '(absent)'}, now {b or '(absent)'}"
|
|
565
|
+
)
|
|
566
|
+
continue
|
|
567
|
+
lines.append(f" {key}: recorded {was!r}, now {now!r}")
|
|
568
|
+
return lines
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# --- the file -----------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def build_document(
|
|
575
|
+
module: ModuleIR,
|
|
576
|
+
entries: dict,
|
|
577
|
+
skips: list[Skip],
|
|
578
|
+
rtol: float = DEFAULT_RTOL,
|
|
579
|
+
atol: float = DEFAULT_ATOL,
|
|
580
|
+
tolerances: dict | None = None,
|
|
581
|
+
environment: dict | None = None,
|
|
582
|
+
) -> dict:
|
|
583
|
+
"""The golden file's contents.
|
|
584
|
+
|
|
585
|
+
Deliberately free of anything that varies run to run — no timestamps, no
|
|
586
|
+
paths, no machine identity — so the only reason for a diff in review is a
|
|
587
|
+
changed answer.
|
|
588
|
+
|
|
589
|
+
`tolerances` maps an entry key to `{"rtol": ..., "atol": ..., "reason":
|
|
590
|
+
...}` for entries whose own algorithm cannot be held to the default; every
|
|
591
|
+
entry gets an explicit tolerance either way, so what a routine is held to
|
|
592
|
+
is visible in the file rather than implied by a default in the code.
|
|
593
|
+
"""
|
|
594
|
+
tolerances = tolerances or {}
|
|
595
|
+
entries = {
|
|
596
|
+
key: {
|
|
597
|
+
**entry,
|
|
598
|
+
"tolerance": {
|
|
599
|
+
"rtol": rtol,
|
|
600
|
+
"atol": atol,
|
|
601
|
+
**{k: v for k, v in tolerances.get(key, {}).items()},
|
|
602
|
+
},
|
|
603
|
+
}
|
|
604
|
+
for key, entry in entries.items()
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
"format": FORMAT_VERSION,
|
|
608
|
+
"service": module.name,
|
|
609
|
+
"language": module.language,
|
|
610
|
+
# The fallback for an entry that has no tolerance of its own — a
|
|
611
|
+
# format-1 file, or one hand-written before this field existed.
|
|
612
|
+
"tolerance": {"rtol": rtol, "atol": atol},
|
|
613
|
+
"provenance": environment if environment is not None else provenance(),
|
|
614
|
+
# Insertion order, NOT sorted: call order is part of the fixture.
|
|
615
|
+
# Legacy F77 keeps state in COMMON blocks — PVTINI initialises what
|
|
616
|
+
# PVTRS and PVTBO then read — so replaying alphabetically returns
|
|
617
|
+
# different (and wrong) numbers than recording did. Sorting these keys
|
|
618
|
+
# made `verify` fail immediately after `record`, against an unchanged
|
|
619
|
+
# build.
|
|
620
|
+
"entries": entries,
|
|
621
|
+
"skipped": {skip.key: skip.reason for skip in sorted(skips, key=lambda s: s.key)},
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def write(path: Path, document: dict) -> None:
|
|
626
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
627
|
+
path.write_text(json.dumps(document, indent=2, sort_keys=False) + "\n")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def read(path: Path) -> dict:
|
|
631
|
+
return json.loads(path.read_text())
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def coverage(document: dict) -> tuple[int, int]:
|
|
635
|
+
"""(recorded, skipped) — so a golden file covering three of forty symbols
|
|
636
|
+
cannot be mistaken for a clean bill of health."""
|
|
637
|
+
return len(document.get("entries") or {}), len(document.get("skipped") or {})
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# --- comparison ---------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _close(recorded, current, rtol: float, atol: float) -> bool:
|
|
644
|
+
if isinstance(recorded, bool) or isinstance(current, bool):
|
|
645
|
+
return recorded == current
|
|
646
|
+
if isinstance(recorded, (int, float)) and isinstance(current, (int, float)):
|
|
647
|
+
if math.isnan(recorded) and math.isnan(current):
|
|
648
|
+
# NaN != NaN, but a routine that returned NaN before and returns
|
|
649
|
+
# NaN now has not regressed — it is the *change* that matters.
|
|
650
|
+
return True
|
|
651
|
+
return math.isclose(recorded, current, rel_tol=rtol, abs_tol=atol)
|
|
652
|
+
if isinstance(recorded, list) and isinstance(current, list):
|
|
653
|
+
return len(recorded) == len(current) and all(
|
|
654
|
+
_close(r, c, rtol, atol) for r, c in zip(recorded, current)
|
|
655
|
+
)
|
|
656
|
+
if isinstance(recorded, dict) and isinstance(current, dict):
|
|
657
|
+
return recorded.keys() == current.keys() and all(
|
|
658
|
+
_close(recorded[k], current[k], rtol, atol) for k in recorded
|
|
659
|
+
)
|
|
660
|
+
return recorded == current
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def entry_tolerance(document: dict, entry: dict) -> tuple[float, float]:
|
|
664
|
+
"""The (rtol, atol) one entry is held to.
|
|
665
|
+
|
|
666
|
+
The entry's own tolerance wins; the document-level one is the fallback for
|
|
667
|
+
a format-1 file, and the module defaults are the fallback for that.
|
|
668
|
+
"""
|
|
669
|
+
fallback = document.get("tolerance") or {}
|
|
670
|
+
own = entry.get("tolerance") or {}
|
|
671
|
+
return (
|
|
672
|
+
own.get("rtol", fallback.get("rtol", DEFAULT_RTOL)),
|
|
673
|
+
own.get("atol", fallback.get("atol", DEFAULT_ATOL)),
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def compare(
|
|
678
|
+
document: dict,
|
|
679
|
+
results: dict,
|
|
680
|
+
errors: dict | None = None,
|
|
681
|
+
effects: dict | None = None,
|
|
682
|
+
environment: dict | None = None,
|
|
683
|
+
) -> list[str]:
|
|
684
|
+
"""Differences between a recorded golden document and a fresh replay.
|
|
685
|
+
|
|
686
|
+
Returns human-readable lines; empty means the answers are unchanged. A
|
|
687
|
+
call that now raises, or an entry point that disappeared, counts as a
|
|
688
|
+
difference: an API that silently lost a symbol between regenerates is
|
|
689
|
+
exactly the drift this is here to catch.
|
|
690
|
+
|
|
691
|
+
Pass `environment` (from `provenance()`) to have any toolchain difference
|
|
692
|
+
reported alongside the numbers. It is never a difference on its own — a
|
|
693
|
+
new compiler that returns the same answers is not a regression — but when
|
|
694
|
+
the answers *have* moved it is the first thing anyone needs to know, and
|
|
695
|
+
re-recording would have destroyed it.
|
|
696
|
+
"""
|
|
697
|
+
errors = errors or {}
|
|
698
|
+
|
|
699
|
+
differences = []
|
|
700
|
+
for key, entry in (document.get("entries") or {}).items():
|
|
701
|
+
rtol, atol = entry_tolerance(document, entry)
|
|
702
|
+
if key in errors:
|
|
703
|
+
differences.append(f"{key}: recorded, but this build cannot call it — {errors[key]}")
|
|
704
|
+
continue
|
|
705
|
+
if key not in results:
|
|
706
|
+
differences.append(f"{key}: recorded but missing from this build")
|
|
707
|
+
continue
|
|
708
|
+
expected, actual = entry.get("result"), results[key]
|
|
709
|
+
if not _close(expected, actual, rtol, atol):
|
|
710
|
+
differences.append(
|
|
711
|
+
f"{key}: expected {expected!r}, got {actual!r} (rtol={rtol}, atol={atol})"
|
|
712
|
+
)
|
|
713
|
+
if effects is None:
|
|
714
|
+
continue
|
|
715
|
+
# An in-place output that stopped being written is a silent wrong
|
|
716
|
+
# answer, not a missing feature: the caller's array simply keeps
|
|
717
|
+
# whatever it held before the call.
|
|
718
|
+
recorded_effects = entry.get("argument_effects") or {}
|
|
719
|
+
produced = effects.get(key) or {}
|
|
720
|
+
for index, expected_effect in recorded_effects.items():
|
|
721
|
+
if index not in produced:
|
|
722
|
+
differences.append(
|
|
723
|
+
f"{key}: argument {index} was written in place when recorded, "
|
|
724
|
+
"and this build does not write it"
|
|
725
|
+
)
|
|
726
|
+
elif not _close(expected_effect, produced[index], rtol, atol):
|
|
727
|
+
differences.append(
|
|
728
|
+
f"{key}: argument {index} after the call: expected "
|
|
729
|
+
f"{expected_effect!r}, got {produced[index]!r}"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
if differences and environment is not None:
|
|
733
|
+
moved = provenance_differences(document.get("provenance"), environment)
|
|
734
|
+
if moved:
|
|
735
|
+
differences.append(
|
|
736
|
+
"the toolchain also changed since these values were recorded — "
|
|
737
|
+
"a legitimate platform difference looks exactly like a regression, "
|
|
738
|
+
"so rule this out before re-recording:\n" + "\n".join(moved)
|
|
739
|
+
)
|
|
740
|
+
else:
|
|
741
|
+
differences.append(
|
|
742
|
+
"the toolchain is unchanged since these values were recorded "
|
|
743
|
+
"(platform, compilers, numpy and native source digests all match), "
|
|
744
|
+
"so this is a change in behaviour, not a platform difference."
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
return differences
|