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/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