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.
Files changed (50) hide show
  1. nativegate/__init__.py +1 -0
  2. nativegate/__main__.py +4 -0
  3. nativegate/buildinfo.py +344 -0
  4. nativegate/cli.py +2007 -0
  5. nativegate/config.py +991 -0
  6. nativegate/declared_invariants.py +565 -0
  7. nativegate/discovery.py +167 -0
  8. nativegate/driverbuild.py +626 -0
  9. nativegate/drivers/__init__.py +5 -0
  10. nativegate/drivers/cpp.py +616 -0
  11. nativegate/drivers/fortran.py +507 -0
  12. nativegate/generators/__init__.py +0 -0
  13. nativegate/generators/cmake_gen.py +101 -0
  14. nativegate/generators/docker_gen.py +614 -0
  15. nativegate/generators/error_gen.py +104 -0
  16. nativegate/generators/f2py_gen.py +91 -0
  17. nativegate/generators/gateway_gen.py +110 -0
  18. nativegate/generators/golden_gen.py +50 -0
  19. nativegate/generators/k8s_gen.py +212 -0
  20. nativegate/generators/mcp_gen.py +281 -0
  21. nativegate/generators/middleware_gen.py +717 -0
  22. nativegate/generators/pybind_gen.py +406 -0
  23. nativegate/generators/pyproject_gen.py +61 -0
  24. nativegate/generators/python_pkg_gen.py +1164 -0
  25. nativegate/generators/test_gen.py +160 -0
  26. nativegate/golden.py +747 -0
  27. nativegate/invariants.py +532 -0
  28. nativegate/ir.py +789 -0
  29. nativegate/lattice.py +350 -0
  30. nativegate/locking.py +216 -0
  31. nativegate/oracle.py +904 -0
  32. nativegate/parsers/__init__.py +0 -0
  33. nativegate/parsers/cpp.py +105 -0
  34. nativegate/parsers/cpp_ast.py +1652 -0
  35. nativegate/parsers/cpp_regex.py +812 -0
  36. nativegate/parsers/fixed_form.py +868 -0
  37. nativegate/parsers/fortran.py +157 -0
  38. nativegate/parsers/fortran_fparser.py +1116 -0
  39. nativegate/parsers/fortran_regex.py +686 -0
  40. nativegate/preprocess.py +335 -0
  41. nativegate/structural_invariants.py +762 -0
  42. nativegate/suggest.py +208 -0
  43. nativegate/templates/__init__.py +20 -0
  44. nativegate/templates/golden_test_template.py +248 -0
  45. nativegate/wire.py +438 -0
  46. nativegate-0.1.0.dist-info/METADATA +547 -0
  47. nativegate-0.1.0.dist-info/RECORD +50 -0
  48. nativegate-0.1.0.dist-info/WHEEL +5 -0
  49. nativegate-0.1.0.dist-info/entry_points.txt +3 -0
  50. 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