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/oracle.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""T5 — the oracle comparator: is the Python binding faithful to the native code?
|
|
2
|
+
|
|
3
|
+
Spec `design-verification-layers.md` sections 2.1, 2.2, 2.6, 2.10, section 4
|
|
4
|
+
rule 6. Read those before changing anything here.
|
|
5
|
+
|
|
6
|
+
This module wires together everything the earlier tasks built:
|
|
7
|
+
|
|
8
|
+
* `buildinfo` (T1) — extracted compile flags, the safety gate, the pinned
|
|
9
|
+
environment.
|
|
10
|
+
* `wire` (T2) — the bitwise wire protocol shared by both sides, and
|
|
11
|
+
`slots_for_entry`, the single source of truth for "which slots does this
|
|
12
|
+
call produce".
|
|
13
|
+
* `drivers.fortran.generate_driver` (T3) — the native driver, generated from
|
|
14
|
+
`golden.json`'s recorded entries, never from `plan()` (spec section 2.2).
|
|
15
|
+
* `driverbuild` (T4) — compiles the driver TU, links it against the
|
|
16
|
+
extension's own built objects, runs it under the pinned environment.
|
|
17
|
+
* `golden` (T1, layer 1) — `invoke()` executes one recorded call against an
|
|
18
|
+
imported package exactly the way `golden record`/`golden verify` do, which
|
|
19
|
+
is what makes the driver and the Python side "the same calls by
|
|
20
|
+
construction" (spec section 2.2).
|
|
21
|
+
|
|
22
|
+
`oracle_check` runs both paths in the same build, in file order, and
|
|
23
|
+
compares every slot **bitwise** — hex text equality, never `math.isclose`,
|
|
24
|
+
because spec section 2.1 is emphatic that there is no rounding difference to
|
|
25
|
+
allow for. `oracle_show` prints coverage and slot layout without compiling or
|
|
26
|
+
running anything.
|
|
27
|
+
|
|
28
|
+
**Sequence discipline (spec section 2.2 / section 4 rule 6).** Both drivers
|
|
29
|
+
execute the file's entries in file order. If the Python side cannot execute
|
|
30
|
+
an entry the driver ran, or the driver skipped an entry the Python side can
|
|
31
|
+
run, that is a hard failure naming the entry — never a skip — and nothing at
|
|
32
|
+
or after the divergence point is compared, because legacy Fortran carries
|
|
33
|
+
state across calls in COMMON blocks and a downstream comparison after a
|
|
34
|
+
divergence is measuring nothing.
|
|
35
|
+
|
|
36
|
+
**Classification (spec section 2.10).** `_classify` turns a set of mismatched
|
|
37
|
+
slots for one call into one of the table's six readings. The table's
|
|
38
|
+
left-hand "signature" column is a description of a *symptom*, not a formal
|
|
39
|
+
predicate, so the boundaries between "wildly different" / "~7 significant
|
|
40
|
+
digits" / "everything differs slightly, uniformly" are judgement calls here
|
|
41
|
+
— documented at the point of the threshold — rather than in the spec text
|
|
42
|
+
itself. A mismatch that fits none of the five specific signatures still gets
|
|
43
|
+
a diagnostic message, just not one of the spec's quoted readings.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import importlib
|
|
49
|
+
import json
|
|
50
|
+
import math
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import shutil
|
|
54
|
+
import sys
|
|
55
|
+
import tempfile
|
|
56
|
+
from dataclasses import dataclass, field
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from . import buildinfo, driverbuild, golden
|
|
60
|
+
from . import wire
|
|
61
|
+
from .drivers.fortran import generate_driver as generate_fortran_driver
|
|
62
|
+
from .drivers.cpp import generate_driver as generate_cpp_driver
|
|
63
|
+
from .ir import FunctionDef, ModuleIR, module_from_dict
|
|
64
|
+
from .wire import Slot, format_value, slots_for_entry, unpack_float
|
|
65
|
+
|
|
66
|
+
SERVICES_DIR = "services"
|
|
67
|
+
IR_RELATIVE_PATH = Path(".nativegate") / "ir.json"
|
|
68
|
+
ORACLE_FILENAME = "oracle.json"
|
|
69
|
+
ORACLE_FORMAT_VERSION = 1
|
|
70
|
+
|
|
71
|
+
# --- spec section 2.10's table, quoted verbatim (the "reading" column) -----
|
|
72
|
+
|
|
73
|
+
READING_ARGUMENT_ORDER = "argument order or intent is wrong"
|
|
74
|
+
READING_TRANSPOSITION = "row/column-major transposition"
|
|
75
|
+
READING_NARROWING = "`float32` narrowing somewhere in the binding"
|
|
76
|
+
READING_INPLACE_MISSING = "in-place output is not being surfaced"
|
|
77
|
+
READING_SEQUENCE_DIVERGED = (
|
|
78
|
+
"the two paths diverged before comparison — fix the entry, nothing "
|
|
79
|
+
"downstream is meaningful"
|
|
80
|
+
)
|
|
81
|
+
READING_FLAGS_OR_LINK = (
|
|
82
|
+
"the driver was not linked against the extension's objects, or flags "
|
|
83
|
+
"differ — check the extracted flags first"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class OracleError(RuntimeError):
|
|
88
|
+
"""Base for every hard oracle failure. Never a skip (spec section 2.2)."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SequenceDivergenceError(OracleError):
|
|
92
|
+
"""The driver and the Python side ran a different set of entries."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- results -----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class OracleReport:
|
|
100
|
+
"""The result of one `oracle_check` run."""
|
|
101
|
+
|
|
102
|
+
covered: int
|
|
103
|
+
skipped: dict = field(default_factory=dict) # key -> reason
|
|
104
|
+
failures: list = field(default_factory=list) # classified, human-readable
|
|
105
|
+
driver_sha256: str = ""
|
|
106
|
+
link_target_sha256: str | None = None
|
|
107
|
+
# T6 (spec section 2.6/4 rules 7-9): populated only when an oracle.json
|
|
108
|
+
# exists for this service. `historical_diff` holds bitwise mismatches
|
|
109
|
+
# against the recorded file's slots — populated ONLY when
|
|
110
|
+
# `historical_diff_refused` is None, because a stale-provenance file is
|
|
111
|
+
# never compared numerically (no tolerance mode exists to fall back to).
|
|
112
|
+
historical_diff: list = field(default_factory=list)
|
|
113
|
+
historical_diff_refused: str | None = None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def passed(self) -> bool:
|
|
117
|
+
# A check covering zero entries is a failure, not a pass (spec
|
|
118
|
+
# section 2.9's "N covered, M skipped" coverage-reporting rule).
|
|
119
|
+
# The historical diff against a recorded oracle.json is provenance,
|
|
120
|
+
# not the gate (spec section 2.6): it never affects `passed`.
|
|
121
|
+
return not self.failures and self.covered > 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class ShowEntry:
|
|
126
|
+
key: str
|
|
127
|
+
arguments: list
|
|
128
|
+
slots: list # list[str] — str(Slot), or None if the IR has no match
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ShowReport:
|
|
133
|
+
entries: list # list[ShowEntry]
|
|
134
|
+
skipped: dict # key -> reason
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --- the pure comparator (no I/O — this is what the unit tests exercise) ---
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _numeric(value_text: str) -> float | None:
|
|
141
|
+
"""Best-effort float for a wire `<value>` text.
|
|
142
|
+
|
|
143
|
+
16 lowercase hex digits is `pack_float`'s float channel; anything else
|
|
144
|
+
that parses as a plain float (an int slot's decimal text, say) is
|
|
145
|
+
accepted too, so classification does not need to know the entry's type
|
|
146
|
+
to reason about magnitude. Returns None for a string/bool value the
|
|
147
|
+
numeric heuristics below cannot use.
|
|
148
|
+
"""
|
|
149
|
+
if len(value_text) == 16 and re.fullmatch(r"[0-9a-f]{16}", value_text):
|
|
150
|
+
try:
|
|
151
|
+
return unpack_float(value_text)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
return float(value_text)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _classify(key: str, driver: dict, python: dict) -> str | None:
|
|
161
|
+
"""One mismatch message for one call, or None if every slot agreed.
|
|
162
|
+
|
|
163
|
+
`driver`/`python` are `{Slot: value_text}` for this one entry key.
|
|
164
|
+
Comparison is the wire's `<value>` TEXT, i.e. bitwise for floats
|
|
165
|
+
(spec section 2.1: "no rounding allowance, because there is no rounding
|
|
166
|
+
difference to allow for").
|
|
167
|
+
"""
|
|
168
|
+
driver_keys = set(driver)
|
|
169
|
+
python_keys = set(python)
|
|
170
|
+
common = driver_keys & python_keys
|
|
171
|
+
mismatched = {s for s in common if driver[s] != python[s]}
|
|
172
|
+
missing_in_python = driver_keys - python_keys
|
|
173
|
+
missing_in_driver = python_keys - driver_keys
|
|
174
|
+
|
|
175
|
+
if not mismatched and not missing_in_python and not missing_in_driver:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# "return is right, arg: slots missing" -> in-place output not surfaced.
|
|
179
|
+
return_slots = {s for s in driver_keys if s.role == "return"}
|
|
180
|
+
arg_slots_missing_in_python = {s for s in missing_in_python if s.role == "arg"}
|
|
181
|
+
if (
|
|
182
|
+
arg_slots_missing_in_python
|
|
183
|
+
and arg_slots_missing_in_python == missing_in_python
|
|
184
|
+
and not missing_in_driver
|
|
185
|
+
and not (mismatched & return_slots)
|
|
186
|
+
):
|
|
187
|
+
return (
|
|
188
|
+
f"{key}: {READING_INPLACE_MISSING} — the return slot(s) match, "
|
|
189
|
+
f"but arg slot(s) {sorted(str(s) for s in arg_slots_missing_in_python)} "
|
|
190
|
+
"the driver printed never appeared on the Python side"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if missing_in_python or missing_in_driver:
|
|
194
|
+
# A structural mismatch that does not fit the "arg-only missing"
|
|
195
|
+
# signature above — not one of spec section 2.10's five specific
|
|
196
|
+
# rows, so this is diagnostic text rather than a quoted reading.
|
|
197
|
+
return (
|
|
198
|
+
f"{key}: driver and Python printed a different set of slots "
|
|
199
|
+
f"(driver-only: {sorted(str(s) for s in missing_in_python)}, "
|
|
200
|
+
f"python-only: {sorted(str(s) for s in missing_in_driver)})"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Every slot present on both sides; classify the numeric disagreement.
|
|
204
|
+
all_slots = sorted(common, key=str)
|
|
205
|
+
rel_errors: dict[Slot, float] = {}
|
|
206
|
+
for slot in mismatched:
|
|
207
|
+
dv, pv = _numeric(driver[slot]), _numeric(python[slot])
|
|
208
|
+
if dv is None or pv is None or not math.isfinite(dv) or not math.isfinite(pv):
|
|
209
|
+
# Non-finite values (inf/nan on either side, e.g. from a real
|
|
210
|
+
# overflow one side hit and the other didn't) can never land in
|
|
211
|
+
# any of the small-relative-error buckets below — dividing by an
|
|
212
|
+
# infinite `dv` or subtracting inf-from-inf produces `nan`, which
|
|
213
|
+
# fails every `>=`/`<=` threshold check and would otherwise fall
|
|
214
|
+
# through to the generic "no signature" message even when the
|
|
215
|
+
# mismatch is obviously severe. Treat it as maximally wrong.
|
|
216
|
+
rel_errors[slot] = float("inf")
|
|
217
|
+
continue
|
|
218
|
+
rel_errors[slot] = abs(pv - dv) / abs(dv) if dv != 0 else (0.0 if pv == 0 else float("inf"))
|
|
219
|
+
|
|
220
|
+
all_slots_mismatched = mismatched == set(all_slots)
|
|
221
|
+
|
|
222
|
+
if all_slots_mismatched and len(all_slots) > 1:
|
|
223
|
+
driver_values = [_numeric(driver[s]) for s in all_slots]
|
|
224
|
+
python_values = [_numeric(python[s]) for s in all_slots]
|
|
225
|
+
if None not in driver_values and None not in python_values and sorted(
|
|
226
|
+
driver_values
|
|
227
|
+
) == sorted(python_values):
|
|
228
|
+
# "array slots are right but permuted": the same multiset of
|
|
229
|
+
# values reappears at different slot positions.
|
|
230
|
+
return (
|
|
231
|
+
f"{key}: {READING_TRANSPOSITION} — every slot mismatched, "
|
|
232
|
+
"but the same values reappear at different positions"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if all_slots_mismatched:
|
|
236
|
+
# "every slot of one call differs, wildly" — every mismatched slot's
|
|
237
|
+
# relative error is large (>= 1e-3), i.e. not a last-few-bits or
|
|
238
|
+
# last-few-digits disagreement. Applies even to a single-slot call
|
|
239
|
+
# (a call with one observable output is still "every slot" of it).
|
|
240
|
+
if all(e >= 1e-3 for e in rel_errors.values()):
|
|
241
|
+
return (
|
|
242
|
+
f"{key}: {READING_ARGUMENT_ORDER} — every slot of this call "
|
|
243
|
+
"differs, and not by a small amount"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# "values agree to ~7 significant digits then diverge" — a relative
|
|
247
|
+
# error near float32's rounding error (up to half a float32 ULP,
|
|
248
|
+
# ~6e-8, but as small as ~1e-9 depending where the true value falls
|
|
249
|
+
# between two float32 representable values), the size a real(4)/float
|
|
250
|
+
# truncation leaves behind. The window (1e-9, 1e-5) is this module's
|
|
251
|
+
# judgement call for "~7 significant digits", not spec text.
|
|
252
|
+
if rel_errors and all(1e-9 <= e <= 1e-5 for e in rel_errors.values()):
|
|
253
|
+
return (
|
|
254
|
+
f"{key}: {READING_NARROWING} — values agree to roughly 7 "
|
|
255
|
+
f"significant digits, then diverge ({sorted(str(s) for s in mismatched)})"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# "everything differs slightly, uniformly" — every mismatched slot's
|
|
259
|
+
# relative error is tiny (below the narrowing window) — the size a
|
|
260
|
+
# different -O level or FMA contraction leaves behind, not a marshalling
|
|
261
|
+
# bug. Uniformity is judged per call; a caller comparing many entries can
|
|
262
|
+
# additionally look for the same signature recurring across all of them.
|
|
263
|
+
if rel_errors and all(e < 1e-9 for e in rel_errors.values()):
|
|
264
|
+
return (
|
|
265
|
+
f"{key}: {READING_FLAGS_OR_LINK} — every mismatched slot differs "
|
|
266
|
+
f"by a tiny, uniform amount ({sorted(str(s) for s in mismatched)})"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
f"{key}: {len(mismatched)} slot(s) mismatched with no single "
|
|
271
|
+
f"signature from spec section 2.10 ({sorted(str(s) for s in mismatched)})"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def compare_documents(
|
|
276
|
+
order: list,
|
|
277
|
+
driver_skip_reasons: dict,
|
|
278
|
+
driver_slots: dict,
|
|
279
|
+
python_errors: dict,
|
|
280
|
+
python_slots: dict,
|
|
281
|
+
) -> tuple[list, dict, list]:
|
|
282
|
+
"""The pure comparator: file order in, `(covered, skipped, failures)` out.
|
|
283
|
+
|
|
284
|
+
`order` is `document["entries"]`'s key order (spec section 2.2: file
|
|
285
|
+
order, never re-sorted). `driver_skip_reasons`/`python_errors` name
|
|
286
|
+
entries each side could not execute; `driver_slots`/`python_slots` are
|
|
287
|
+
`{key: {Slot: value_text}}` for entries each side DID execute.
|
|
288
|
+
|
|
289
|
+
Sequence discipline (spec section 2.2 / section 4 rule 6): the first key
|
|
290
|
+
where one side ran the call and the other could not stops comparison
|
|
291
|
+
entirely — the returned `failures` holds exactly that one message, and
|
|
292
|
+
`covered`/`skipped` reflect only what was compared before it.
|
|
293
|
+
"""
|
|
294
|
+
covered: list = []
|
|
295
|
+
skipped: dict = {}
|
|
296
|
+
failures: list = []
|
|
297
|
+
|
|
298
|
+
for key in order:
|
|
299
|
+
driver_ran = key not in driver_skip_reasons
|
|
300
|
+
python_ran = key not in python_errors
|
|
301
|
+
if driver_ran != python_ran:
|
|
302
|
+
if driver_ran:
|
|
303
|
+
detail = (
|
|
304
|
+
"the Python side could not execute an entry the driver ran"
|
|
305
|
+
f" ({python_errors.get(key)})"
|
|
306
|
+
)
|
|
307
|
+
else:
|
|
308
|
+
detail = (
|
|
309
|
+
"the driver did not execute an entry the Python side ran"
|
|
310
|
+
f" ({driver_skip_reasons.get(key)})"
|
|
311
|
+
)
|
|
312
|
+
failures.append(f"{key}: {READING_SEQUENCE_DIVERGED} — {detail}")
|
|
313
|
+
return covered, skipped, failures
|
|
314
|
+
|
|
315
|
+
if not driver_ran:
|
|
316
|
+
skipped[key] = driver_skip_reasons.get(key) or python_errors.get(key) or "skipped"
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
message = _classify(key, driver_slots.get(key, {}), python_slots.get(key, {}))
|
|
320
|
+
if message:
|
|
321
|
+
failures.append(message)
|
|
322
|
+
continue
|
|
323
|
+
covered.append(key)
|
|
324
|
+
|
|
325
|
+
return covered, skipped, failures
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- the driver side: turning parsed driver output into {Slot: text} -------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def driver_slots_by_key(document: dict, module: ModuleIR, driver_skipped: dict, stdout: str) -> dict:
|
|
332
|
+
"""`{key: {Slot: value_text}}` from a driver run's stdout, T2-parsed.
|
|
333
|
+
|
|
334
|
+
Only entries with at least one slot appear in the ordering check — an
|
|
335
|
+
entry with no observable output (a void function, no intent(out)/
|
|
336
|
+
intent(inout) arguments) never prints a line, matching T3's own test
|
|
337
|
+
convention (see tests/test_driverbuild.py).
|
|
338
|
+
"""
|
|
339
|
+
functions_by_name = {fn.name: fn for fn in module.functions}
|
|
340
|
+
expected_order: list[str] = []
|
|
341
|
+
for key, entry in document.get("entries", {}).items():
|
|
342
|
+
if key in driver_skipped:
|
|
343
|
+
continue
|
|
344
|
+
fn = functions_by_name.get(entry.get("name") or key)
|
|
345
|
+
if fn is None:
|
|
346
|
+
continue
|
|
347
|
+
if slots_for_entry(entry, fn):
|
|
348
|
+
expected_order.append(key)
|
|
349
|
+
|
|
350
|
+
parsed = wire.parse_lines(stdout.splitlines(), expected_key_order=expected_order)
|
|
351
|
+
return {key: dict(slots) for key, slots in parsed.items()}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- the python side: turning golden.invoke()'s live result into slots -----
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def python_slot_values(slots: list, result, effects: dict) -> dict:
|
|
358
|
+
"""`{Slot: value_text}` for one call's LIVE Python result, T2-formatted.
|
|
359
|
+
|
|
360
|
+
`slots` is `wire.slots_for_entry(entry, fn)` — the expected slot list,
|
|
361
|
+
derived from the entry's recorded shape. `result`/`effects` are what
|
|
362
|
+
`golden.invoke` actually returned for this run (not the recorded
|
|
363
|
+
values) — the whole point of the oracle is to compare a fresh
|
|
364
|
+
execution, not the file. Uses `wire.format_value` (which dispatches to
|
|
365
|
+
`wire.pack_float` for floats), so the Python side goes through the exact
|
|
366
|
+
same hex/decimal/escape rules as the driver (spec section 2.4).
|
|
367
|
+
"""
|
|
368
|
+
values: dict = {}
|
|
369
|
+
return_slots = [s for s in slots if s.role == "return"]
|
|
370
|
+
if len(return_slots) == 1 and return_slots[0].element is None:
|
|
371
|
+
values[return_slots[0]] = format_value(result)
|
|
372
|
+
elif return_slots:
|
|
373
|
+
for s in return_slots:
|
|
374
|
+
values[s] = format_value(result[s.element])
|
|
375
|
+
|
|
376
|
+
for s in slots:
|
|
377
|
+
if s.role != "arg":
|
|
378
|
+
continue
|
|
379
|
+
arg_value = effects.get(str(s.arg_index))
|
|
380
|
+
if arg_value is None:
|
|
381
|
+
continue # surfaces as "missing" in _classify, not a KeyError here
|
|
382
|
+
if s.element is None:
|
|
383
|
+
values[s] = format_value(arg_value)
|
|
384
|
+
else:
|
|
385
|
+
values[s] = format_value(arg_value[s.element])
|
|
386
|
+
return values
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# --- I/O: loading the service, building, running ---------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _load(service_dir: Path) -> tuple[ModuleIR, dict]:
|
|
393
|
+
ir_path = service_dir / IR_RELATIVE_PATH
|
|
394
|
+
if not ir_path.exists():
|
|
395
|
+
raise OracleError(f"no {ir_path} — run `ngate generate <name>` first")
|
|
396
|
+
module = module_from_dict(json.loads(ir_path.read_text()))
|
|
397
|
+
|
|
398
|
+
golden_path = service_dir / golden.GOLDEN_FILENAME
|
|
399
|
+
if not golden_path.exists():
|
|
400
|
+
raise OracleError(
|
|
401
|
+
f"no {golden_path} — run `ngate golden record <name>` first "
|
|
402
|
+
"(the oracle replays golden.json's recorded entries, spec section 2.2)"
|
|
403
|
+
)
|
|
404
|
+
document = golden.read(golden_path)
|
|
405
|
+
return module, document
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _fortran_sources(service_dir: Path) -> tuple[Path, list]:
|
|
409
|
+
"""The directory and filenames the extension actually compiles.
|
|
410
|
+
|
|
411
|
+
`native/_expanded/` when non-empty (the CLI's rewritten copies — INCLUDE
|
|
412
|
+
expansion, kind-parameter resolution — are what the real extension
|
|
413
|
+
compiles), else `native/` directly.
|
|
414
|
+
"""
|
|
415
|
+
expanded = service_dir / "native" / "_expanded"
|
|
416
|
+
directory = expanded if expanded.is_dir() and any(expanded.iterdir()) else service_dir / "native"
|
|
417
|
+
names = sorted(
|
|
418
|
+
p.name
|
|
419
|
+
for p in directory.iterdir()
|
|
420
|
+
if p.is_file() and p.suffix.lower() in (".f", ".f90", ".f95", ".f03")
|
|
421
|
+
)
|
|
422
|
+
if not names:
|
|
423
|
+
raise OracleError(f"no Fortran sources found under {directory}")
|
|
424
|
+
return directory, names
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _facade_source_name(module: ModuleIR, source_names: list) -> str:
|
|
428
|
+
facade = Path(module.source_file).name
|
|
429
|
+
if facade in source_names:
|
|
430
|
+
return facade
|
|
431
|
+
raise OracleError(
|
|
432
|
+
f"the IR's source file {facade!r} is not among the extension's "
|
|
433
|
+
f"compiled sources {source_names}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# --- C++ (T7 integration): the same jobs `_fortran_sources`/
|
|
438
|
+
# `_facade_source_name` do for the Fortran path, above ---------------------
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _cpp_sources(service_dir: Path) -> tuple[Path, list]:
|
|
442
|
+
"""The directory and filenames of the C++ implementation sources the
|
|
443
|
+
extension actually compiles (`native/`; C++ has no `_expanded` step)."""
|
|
444
|
+
directory = service_dir / "native"
|
|
445
|
+
names = sorted(
|
|
446
|
+
p.name
|
|
447
|
+
for p in directory.iterdir()
|
|
448
|
+
if p.is_file() and p.suffix.lower() in (".cpp", ".cc", ".cxx")
|
|
449
|
+
)
|
|
450
|
+
if not names:
|
|
451
|
+
raise OracleError(f"no C++ sources found under {directory}")
|
|
452
|
+
return directory, names
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def compiled_sources(service_dir: Path, language: str) -> tuple[Path, list]:
|
|
456
|
+
"""The sources the extension actually compiles, for either language.
|
|
457
|
+
|
|
458
|
+
Public because `golden record` must hash exactly the same files this
|
|
459
|
+
module hashes when it verifies. Recording a digest set the check then
|
|
460
|
+
computes differently would report every source as "now (absent)" and
|
|
461
|
+
refuse the historical diff — a provenance field is only worth having if
|
|
462
|
+
both sides derive it identically, so they share one resolver.
|
|
463
|
+
"""
|
|
464
|
+
return _cpp_sources(service_dir) if language == "cpp" else _fortran_sources(service_dir)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _cpp_facade_source_name(module: ModuleIR, source_names: list) -> str:
|
|
468
|
+
"""The implementation `.cpp` the IR's header (`module.source_file`)
|
|
469
|
+
pairs with — same-stem, the pairing `quickstart` itself uses
|
|
470
|
+
(`calculator.hpp` <-> `calculator.cpp`). Falls back to the sole source
|
|
471
|
+
when there is exactly one, so a single-file service never needs the
|
|
472
|
+
stems to match by convention."""
|
|
473
|
+
stem = Path(module.source_file).stem
|
|
474
|
+
candidates = [n for n in source_names if Path(n).stem == stem]
|
|
475
|
+
if candidates:
|
|
476
|
+
return candidates[0]
|
|
477
|
+
if len(source_names) == 1:
|
|
478
|
+
return source_names[0]
|
|
479
|
+
raise OracleError(
|
|
480
|
+
f"cannot tell which of the extension's compiled sources {source_names} "
|
|
481
|
+
f"implements the IR's header {module.source_file!r} — expected a "
|
|
482
|
+
"same-stem .cpp, and there is more than one candidate source"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _sanitize_module_name(name: str) -> str:
|
|
487
|
+
return "n2p_oracle_" + re.sub(r"\W", "_", name)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _f2py_extension_cwd(build_dir: Path) -> Path:
|
|
491
|
+
"""Where `driverbuild.build_extension_with_compile_commands` actually
|
|
492
|
+
leaves the built `.so` — see that function's docstring: f2py's meson
|
|
493
|
+
backend "moves the finished extension to cwd" of the subprocess it ran,
|
|
494
|
+
which is a sibling of `build_dir`, not `build_dir` itself."""
|
|
495
|
+
return build_dir.parent / f"{build_dir.name}-f2py-cwd"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _import_extension(module_name: str, search_dir: Path):
|
|
499
|
+
sys.path.insert(0, str(search_dir))
|
|
500
|
+
try:
|
|
501
|
+
importlib.invalidate_caches()
|
|
502
|
+
return importlib.import_module(module_name)
|
|
503
|
+
finally:
|
|
504
|
+
sys.path.pop(0)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _package_namespace(module: ModuleIR, extension):
|
|
508
|
+
"""The object golden.invoke() calls attributes on.
|
|
509
|
+
|
|
510
|
+
f2py nests everything under the enclosing Fortran `module X` block, if
|
|
511
|
+
there is one — the same rule generate_init_py's re-export and
|
|
512
|
+
test_native_oracle.py's `petro_oracle.petro_api` both rely on.
|
|
513
|
+
"""
|
|
514
|
+
if module.fortran_module:
|
|
515
|
+
return getattr(extension, module.fortran_module)
|
|
516
|
+
return extension
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# --- the public entry points ------------------------------------------------
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _oracle_path(service_dir: Path) -> Path:
|
|
523
|
+
return service_dir / ORACLE_FILENAME
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _driver_hash_mismatch_message(recorded_sha: str, fresh_sha: str) -> str:
|
|
527
|
+
return (
|
|
528
|
+
"the recorded oracle.json's driver_sha256 "
|
|
529
|
+
f"({recorded_sha}) does not match the freshly generated driver "
|
|
530
|
+
f"({fresh_sha}): the driver generator changed since this file was "
|
|
531
|
+
"recorded (design-verification-layers.md section 2.6 — 'every "
|
|
532
|
+
"nativegate generator change invalidates every recorded oracle "
|
|
533
|
+
"file'). Re-recording the file is part of upgrading the tool: run "
|
|
534
|
+
"`ngate oracle record` again once you have reviewed the new "
|
|
535
|
+
"driver output."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
_PROVENANCE_MATCH_FIELDS = (
|
|
540
|
+
"platform",
|
|
541
|
+
"fortran_compiler",
|
|
542
|
+
"cxx_compiler",
|
|
543
|
+
"compile_flags",
|
|
544
|
+
"sources",
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _provenance_matches_build(recorded: dict, current: dict) -> bool:
|
|
549
|
+
"""spec section 2.6/4 rule 9: only platform, compiler, flags and source
|
|
550
|
+
digests gate the historical (bitwise) comparison — a file pinned to a
|
|
551
|
+
different build image is refused, never tolerance-compared."""
|
|
552
|
+
return all(recorded.get(f) == current.get(f) for f in _PROVENANCE_MATCH_FIELDS)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _provenance_mismatch_message(name: str, recorded: dict, current: dict) -> str:
|
|
556
|
+
lines = []
|
|
557
|
+
for field_name in _PROVENANCE_MATCH_FIELDS:
|
|
558
|
+
was, now = recorded.get(field_name), current.get(field_name)
|
|
559
|
+
if was != now:
|
|
560
|
+
lines.append(f" {field_name}: recorded {was!r}, now {now!r}")
|
|
561
|
+
detail = "\n".join(lines)
|
|
562
|
+
return (
|
|
563
|
+
f"refusing the historical comparison against {name}'s recorded "
|
|
564
|
+
"oracle.json: it is pinned to the build image that recorded it "
|
|
565
|
+
"(platform, compiler, extracted flags and source digests), and "
|
|
566
|
+
"this build differs:\n"
|
|
567
|
+
f"{detail}\n"
|
|
568
|
+
"There is no tolerance mode to fall back to (design-verification-"
|
|
569
|
+
"layers.md section 2.6) — this is a usage error, not a numeric "
|
|
570
|
+
"disagreement. The live oracle check above still ran and is "
|
|
571
|
+
"unaffected; re-record oracle.json from this build image if you "
|
|
572
|
+
"want a historical comparison here."
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _historical_bit_diff(recorded_entries: dict, python_slots: dict) -> list[str]:
|
|
577
|
+
"""Bitwise diff of `python_slots` (this run) against `oracle.json`'s
|
|
578
|
+
recorded `entries[*].slots` (spec section 2.6: "diff the recorded bits
|
|
579
|
+
against the fresh run"). No tolerance — hex text equality only."""
|
|
580
|
+
diffs: list[str] = []
|
|
581
|
+
for key, recorded_entry in recorded_entries.items():
|
|
582
|
+
recorded_slots = recorded_entry.get("slots") or {}
|
|
583
|
+
fresh = python_slots.get(key)
|
|
584
|
+
if fresh is None:
|
|
585
|
+
continue # not covered this run (e.g. now skipped) — nothing to diff
|
|
586
|
+
fresh_by_name = {str(slot): value for slot, value in fresh.items()}
|
|
587
|
+
for slot_name, recorded_value in recorded_slots.items():
|
|
588
|
+
fresh_value = fresh_by_name.get(slot_name)
|
|
589
|
+
if fresh_value is None:
|
|
590
|
+
diffs.append(f"{key}.{slot_name}: recorded but not produced this run")
|
|
591
|
+
elif fresh_value != recorded_value:
|
|
592
|
+
diffs.append(
|
|
593
|
+
f"{key}.{slot_name}: recorded {recorded_value}, now {fresh_value}"
|
|
594
|
+
)
|
|
595
|
+
for slot_name in fresh_by_name:
|
|
596
|
+
if slot_name not in recorded_slots:
|
|
597
|
+
diffs.append(f"{key}.{slot_name}: produced this run but not recorded")
|
|
598
|
+
return diffs
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def oracle_check(
|
|
602
|
+
name: str,
|
|
603
|
+
*,
|
|
604
|
+
service_dir: Path | None = None,
|
|
605
|
+
keep_build: bool = False,
|
|
606
|
+
_artifacts: dict | None = None,
|
|
607
|
+
) -> OracleReport:
|
|
608
|
+
"""Generate the driver, build+run it, run the same entries through a
|
|
609
|
+
freshly-built Python extension (same build, spec section 2.6), compare
|
|
610
|
+
bitwise. Never returns with `covered == 0` reported as a pass.
|
|
611
|
+
|
|
612
|
+
When `services/<name>/oracle.json` exists (T6, spec section 2.6/2.7),
|
|
613
|
+
additionally: verifies the file's `driver_sha256` against the freshly
|
|
614
|
+
generated driver (hard failure, loudly, on mismatch — never silent); and,
|
|
615
|
+
only when the file's recorded provenance matches this build, diffs its
|
|
616
|
+
recorded bits against this run's — bitwise, no tolerance mode. A
|
|
617
|
+
provenance mismatch refuses that historical comparison but never blocks
|
|
618
|
+
the live check above.
|
|
619
|
+
|
|
620
|
+
`_artifacts`, if given, is filled in with `python_slots`/`document`/
|
|
621
|
+
`current_provenance` from this run — an internal seam `oracle_record`
|
|
622
|
+
uses to write `oracle.json` from an already-passing check without
|
|
623
|
+
re-running it, never a public API.
|
|
624
|
+
"""
|
|
625
|
+
service_dir = Path(service_dir) if service_dir is not None else Path(SERVICES_DIR) / name
|
|
626
|
+
module, document = _load(service_dir)
|
|
627
|
+
|
|
628
|
+
oracle_path = _oracle_path(service_dir)
|
|
629
|
+
recorded_document = json.loads(oracle_path.read_text()) if oracle_path.exists() else None
|
|
630
|
+
|
|
631
|
+
is_cpp = module.language == "cpp"
|
|
632
|
+
if is_cpp:
|
|
633
|
+
# T7's driver — see drivers/cpp.py — needs the header(s) it #includes
|
|
634
|
+
# to see the declarations it calls; the IR's own source_file is that
|
|
635
|
+
# header (spec section 4: passed explicitly rather than embedding
|
|
636
|
+
# whatever absolute path a parser run captured).
|
|
637
|
+
driver = generate_cpp_driver(document, module, Path(module.source_file).name)
|
|
638
|
+
else:
|
|
639
|
+
driver = generate_fortran_driver(document, module)
|
|
640
|
+
|
|
641
|
+
if recorded_document is not None:
|
|
642
|
+
recorded_driver_sha = (recorded_document.get("provenance") or {}).get("driver_sha256")
|
|
643
|
+
if recorded_driver_sha and recorded_driver_sha != driver.driver_sha256:
|
|
644
|
+
raise OracleError(_driver_hash_mismatch_message(recorded_driver_sha, driver.driver_sha256))
|
|
645
|
+
|
|
646
|
+
work_root = Path(tempfile.mkdtemp(prefix=f"n2p_oracle_{re.sub(r'[^A-Za-z0-9_]', '_', name)}_"))
|
|
647
|
+
try:
|
|
648
|
+
if is_cpp:
|
|
649
|
+
sources_dir, source_names = compiled_sources(service_dir, module.language)
|
|
650
|
+
extension_source_name = _cpp_facade_source_name(module, source_names)
|
|
651
|
+
module_name = _sanitize_module_name(name)
|
|
652
|
+
|
|
653
|
+
build_dir = work_root / "ext_build"
|
|
654
|
+
compile_commands = driverbuild.build_cxx_extension_with_compile_commands(
|
|
655
|
+
module,
|
|
656
|
+
Path(module.source_file).name,
|
|
657
|
+
[sources_dir / n for n in source_names],
|
|
658
|
+
module_name,
|
|
659
|
+
build_dir,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
driver_result = driverbuild.build_and_run_driver(
|
|
663
|
+
driver.source,
|
|
664
|
+
compile_commands,
|
|
665
|
+
extension_source_name,
|
|
666
|
+
source_names,
|
|
667
|
+
work_root / "driver",
|
|
668
|
+
language="cpp",
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
driver_slots = driver_slots_by_key(document, module, driver.skipped, driver_result.stdout)
|
|
672
|
+
|
|
673
|
+
# The pybind11 module symbol is `<module.name>_cpp` (see
|
|
674
|
+
# generators/pybind_gen.py / cmake_gen.py) — `module_name` above
|
|
675
|
+
# is `_sanitize_module_name(name)`, used only to name the CMake
|
|
676
|
+
# project, not the compiled Python extension symbol.
|
|
677
|
+
extension = _import_extension(
|
|
678
|
+
f"{module.name}_cpp", driverbuild._cxx_extension_search_dir(build_dir)
|
|
679
|
+
)
|
|
680
|
+
package = _package_namespace(module, extension)
|
|
681
|
+
else:
|
|
682
|
+
sources_dir, source_names = compiled_sources(service_dir, module.language)
|
|
683
|
+
extension_source_name = _facade_source_name(module, source_names)
|
|
684
|
+
module_name = _sanitize_module_name(name)
|
|
685
|
+
|
|
686
|
+
build_dir = work_root / "ext_build"
|
|
687
|
+
compile_commands = driverbuild.build_extension_with_compile_commands(
|
|
688
|
+
[sources_dir / n for n in source_names], module_name, build_dir
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
driver_result = driverbuild.build_and_run_driver(
|
|
692
|
+
driver.source,
|
|
693
|
+
compile_commands,
|
|
694
|
+
extension_source_name,
|
|
695
|
+
source_names,
|
|
696
|
+
work_root / "driver",
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
driver_slots = driver_slots_by_key(document, module, driver.skipped, driver_result.stdout)
|
|
700
|
+
|
|
701
|
+
extension = _import_extension(module_name, _f2py_extension_cwd(build_dir))
|
|
702
|
+
package = _package_namespace(module, extension)
|
|
703
|
+
|
|
704
|
+
functions_by_name = {fn.name: fn for fn in module.functions}
|
|
705
|
+
python_slots: dict = {}
|
|
706
|
+
python_errors: dict = {}
|
|
707
|
+
|
|
708
|
+
# Spec section 2.8 / section 4 rule 3: the harness SETS thread
|
|
709
|
+
# pinning in both processes it runs. The driver gets it via
|
|
710
|
+
# driverbuild.run_driver; the in-process Python call gets it here.
|
|
711
|
+
old_env = {k: os.environ.get(k) for k in buildinfo.pinned_environment()}
|
|
712
|
+
os.environ.update(buildinfo.pinned_environment())
|
|
713
|
+
try:
|
|
714
|
+
for key, entry in document.get("entries", {}).items():
|
|
715
|
+
if key in driver.skipped:
|
|
716
|
+
# The driver never attempted this entry; mark it as not
|
|
717
|
+
# run on the Python side too (rather than leaving it out
|
|
718
|
+
# of both `python_slots` and `python_errors`), so
|
|
719
|
+
# `compare_documents`'s `python_ran = key not in
|
|
720
|
+
# python_errors` check correctly reads this as a mutual
|
|
721
|
+
# skip instead of a one-sided sequence divergence.
|
|
722
|
+
python_errors[key] = driver.skipped[key]
|
|
723
|
+
continue
|
|
724
|
+
fn = functions_by_name.get(entry.get("name") or key)
|
|
725
|
+
if fn is None:
|
|
726
|
+
python_errors[key] = "no matching function in the IR"
|
|
727
|
+
continue
|
|
728
|
+
try:
|
|
729
|
+
result, effects = golden.invoke(entry, package)
|
|
730
|
+
except Exception as exc: # noqa: BLE001 - recorded, not swallowed
|
|
731
|
+
python_errors[key] = f"{type(exc).__name__}: {exc}"
|
|
732
|
+
continue
|
|
733
|
+
slots = slots_for_entry(entry, fn)
|
|
734
|
+
python_slots[key] = python_slot_values(slots, result, effects)
|
|
735
|
+
finally:
|
|
736
|
+
for k, v in old_env.items():
|
|
737
|
+
if v is None:
|
|
738
|
+
os.environ.pop(k, None)
|
|
739
|
+
else:
|
|
740
|
+
os.environ[k] = v
|
|
741
|
+
|
|
742
|
+
order = list(document.get("entries", {}).keys())
|
|
743
|
+
covered, skipped, failures = compare_documents(
|
|
744
|
+
order, driver.skipped, driver_slots, python_errors, python_slots
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
skipped_total = dict(skipped)
|
|
748
|
+
for key, reason in document.get("skipped", {}).items():
|
|
749
|
+
skipped_total.setdefault(key, reason)
|
|
750
|
+
|
|
751
|
+
if not failures and not covered:
|
|
752
|
+
failures = [
|
|
753
|
+
"the check covered zero entries — a check with no coverage "
|
|
754
|
+
"is a failure, not a pass (design-verification-layers.md "
|
|
755
|
+
"section 2.9)"
|
|
756
|
+
]
|
|
757
|
+
|
|
758
|
+
# Same file set `golden record` hashes (see compiled_sources): the
|
|
759
|
+
# digests are what make "the code did not change" checkable rather
|
|
760
|
+
# than asserted, and they only compare if both sides list the same
|
|
761
|
+
# sources.
|
|
762
|
+
current_provenance = golden.provenance(
|
|
763
|
+
sources=[sources_dir / n for n in source_names]
|
|
764
|
+
)
|
|
765
|
+
current_provenance["compile_flags"] = list(driver_result.extracted_flags)
|
|
766
|
+
|
|
767
|
+
historical_diff: list = []
|
|
768
|
+
historical_diff_refused: str | None = None
|
|
769
|
+
if recorded_document is not None:
|
|
770
|
+
recorded_provenance = recorded_document.get("provenance") or {}
|
|
771
|
+
if _provenance_matches_build(recorded_provenance, current_provenance):
|
|
772
|
+
historical_diff = _historical_bit_diff(
|
|
773
|
+
recorded_document.get("entries") or {}, python_slots
|
|
774
|
+
)
|
|
775
|
+
else:
|
|
776
|
+
historical_diff_refused = _provenance_mismatch_message(
|
|
777
|
+
name, recorded_provenance, current_provenance
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
report = OracleReport(
|
|
781
|
+
covered=len(covered),
|
|
782
|
+
skipped=skipped_total,
|
|
783
|
+
failures=failures,
|
|
784
|
+
driver_sha256=driver.driver_sha256,
|
|
785
|
+
link_target_sha256=driver_result.link_target_sha256,
|
|
786
|
+
historical_diff=historical_diff,
|
|
787
|
+
historical_diff_refused=historical_diff_refused,
|
|
788
|
+
)
|
|
789
|
+
if _artifacts is not None:
|
|
790
|
+
_artifacts["python_slots"] = python_slots
|
|
791
|
+
_artifacts["document"] = document
|
|
792
|
+
_artifacts["current_provenance"] = current_provenance
|
|
793
|
+
return report
|
|
794
|
+
finally:
|
|
795
|
+
if not keep_build:
|
|
796
|
+
shutil.rmtree(work_root, ignore_errors=True)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def oracle_show(name: str, *, service_dir: Path | None = None) -> ShowReport:
|
|
800
|
+
"""Coverage and slot layout, without building, compiling, or running
|
|
801
|
+
anything — reads `golden.json` and the IR only."""
|
|
802
|
+
service_dir = Path(service_dir) if service_dir is not None else Path(SERVICES_DIR) / name
|
|
803
|
+
module, document = _load(service_dir)
|
|
804
|
+
functions_by_name = {fn.name: fn for fn in module.functions}
|
|
805
|
+
|
|
806
|
+
entries: list = []
|
|
807
|
+
for key, entry in document.get("entries", {}).items():
|
|
808
|
+
fn = functions_by_name.get(entry.get("name") or key)
|
|
809
|
+
slots = [str(s) for s in slots_for_entry(entry, fn)] if fn is not None else None
|
|
810
|
+
entries.append(ShowEntry(key=key, arguments=entry.get("arguments") or [], slots=slots))
|
|
811
|
+
|
|
812
|
+
skipped = dict(document.get("skipped") or {})
|
|
813
|
+
return ShowReport(entries=entries, skipped=skipped)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# --- T6: `oracle record` and the optional oracle.json -----------------------
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def build_oracle_document(
|
|
820
|
+
name: str,
|
|
821
|
+
module: ModuleIR,
|
|
822
|
+
golden_document: dict,
|
|
823
|
+
python_slots: dict,
|
|
824
|
+
current_provenance: dict,
|
|
825
|
+
driver_sha256: str,
|
|
826
|
+
link_target_sha256: str | None,
|
|
827
|
+
) -> dict:
|
|
828
|
+
"""The `oracle.json` schema (spec section 2.7), from an already-passing
|
|
829
|
+
`oracle_check` run's artifacts.
|
|
830
|
+
|
|
831
|
+
`provenance` is golden's own provenance block (platform, compilers,
|
|
832
|
+
`sources` — "identical to golden's", per spec section 2.7's comment)
|
|
833
|
+
plus this run's `compile_flags` (extracted, per T1), `driver_sha256`
|
|
834
|
+
and `link_target_sha256`. `entries` follow golden.json's own key order
|
|
835
|
+
(call order carries meaning, spec section 2.2/4 rule 7) and hold only
|
|
836
|
+
the arguments and the slots this run actually produced — an entry
|
|
837
|
+
`oracle_check` could not cover (skipped, or a Python-side error) is
|
|
838
|
+
recorded under `skipped` instead, exactly like golden does.
|
|
839
|
+
"""
|
|
840
|
+
provenance = dict(current_provenance)
|
|
841
|
+
provenance["driver_sha256"] = driver_sha256
|
|
842
|
+
provenance["link_target_sha256"] = link_target_sha256
|
|
843
|
+
|
|
844
|
+
entries: dict = {}
|
|
845
|
+
skipped: dict = dict(golden_document.get("skipped") or {})
|
|
846
|
+
for key, entry in golden_document.get("entries", {}).items():
|
|
847
|
+
slots = python_slots.get(key)
|
|
848
|
+
if slots is None:
|
|
849
|
+
skipped.setdefault(key, "not covered by the oracle check that recorded this file")
|
|
850
|
+
continue
|
|
851
|
+
entries[key] = {
|
|
852
|
+
"arguments": entry.get("arguments") or [],
|
|
853
|
+
"slots": {str(slot): value for slot, value in slots.items()},
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
"format": ORACLE_FORMAT_VERSION,
|
|
858
|
+
"service": name,
|
|
859
|
+
"language": module.language,
|
|
860
|
+
"provenance": provenance,
|
|
861
|
+
"entries": entries,
|
|
862
|
+
"skipped": dict(sorted(skipped.items())),
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def write_oracle_document(path: Path, document: dict) -> None:
|
|
867
|
+
"""Canonical serialization (spec section 2.7 / section 4 rule 7):
|
|
868
|
+
`indent=2`, `sort_keys=False` (entry order is call order and carries
|
|
869
|
+
meaning), LF line endings, trailing newline — matching `golden.write()`
|
|
870
|
+
exactly."""
|
|
871
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
872
|
+
path.write_text(json.dumps(document, indent=2, sort_keys=False) + "\n")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def oracle_record(name: str, *, service_dir: Path | None = None, keep_build: bool = False) -> dict:
|
|
876
|
+
"""Run a full `oracle_check`, and on pass, write `services/<name>/
|
|
877
|
+
oracle.json` (spec section 2.6: "optional: write oracle.json from a
|
|
878
|
+
check"). Refuses to write anything if the check fails — a recorded
|
|
879
|
+
file must document a passing build, never a known-broken one.
|
|
880
|
+
"""
|
|
881
|
+
service_dir = Path(service_dir) if service_dir is not None else Path(SERVICES_DIR) / name
|
|
882
|
+
|
|
883
|
+
artifacts: dict = {}
|
|
884
|
+
report = oracle_check(name, service_dir=service_dir, keep_build=keep_build, _artifacts=artifacts)
|
|
885
|
+
if not report.passed:
|
|
886
|
+
raise OracleError(
|
|
887
|
+
f"refusing to record oracle.json for {name!r}: the oracle check "
|
|
888
|
+
f"did not pass ({len(report.failures)} failure(s), {report.covered} "
|
|
889
|
+
"covered) — fix the binding first, a recorded file must document "
|
|
890
|
+
"a passing build"
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
module, _document = _load(service_dir)
|
|
894
|
+
document = build_oracle_document(
|
|
895
|
+
name,
|
|
896
|
+
module,
|
|
897
|
+
artifacts["document"],
|
|
898
|
+
artifacts["python_slots"],
|
|
899
|
+
artifacts["current_provenance"],
|
|
900
|
+
report.driver_sha256,
|
|
901
|
+
report.link_target_sha256,
|
|
902
|
+
)
|
|
903
|
+
write_oracle_document(_oracle_path(service_dir), document)
|
|
904
|
+
return document
|